iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

前兩天講了架構和原語。今天完全不用 MCP SDK,只用 Python 標準函式庫,手寫一個 MCP Server 和一個 Client。

然後拿那支手刻 client 去打 Day 10 用官方 SDK 寫的 server。能對話,就證明 SDK 只是包裝。

https://ithelp.ithome.com.tw/upload/images/20260921/20161224bI9LnNN01V.png

一、JSON-RPC 2.0 只有三種訊息

MCP 建立在 JSON-RPC 2.0 上。Request 帶 idmethod;Response 必須帶回同一個 id,而且 resulterror 只能有一個。

第三種是 Notification,跟 Request 唯一的差別是沒有 id,意思是收到的一方絕對不可以回應。有 id 才要回,沒 id 回了就是 bug。

JSON-RPC 也是對稱的,Server 可以主動發 request 給 Client,昨天講的 Sampling、Elicitation 就靠這點運作。

二、stdio 上一行就是一則訊息

MCP 沒有 LSP 那種 Content-Length 標頭,stdio 上一行一則訊息,換行結束:

def send(msg: dict[str, Any]) -> None:
    sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n")
    sys.stdout.flush()

三、握手完成前不准做事

初始化時 Client 送 initialize,Server 回協定版本和 capabilities,Client 再送一則沒有 id 的 notifications/initialized,握手才算完成。

if method == "notifications/initialized":
    initialized = True
    log("握手完成,客戶端說它準備好了(這是 notification,我不回應)")
    continue

if not initialized and method != "ping":
    err(req_id, -32002, "尚未完成 initialize 握手", {"method": method})
    continue

手刻 server 在收到 notifications/initialized 之前,除了 initializeping,一律用自訂錯誤碼 -32002 拒絕。

關閉階段沒有 shutdown 方法,這跟 LSP 不一樣,MCP 把關閉交給傳輸層,stdio 就是 client 關掉 stdin。

四、實際跑:同一支 client,兩個 server

raw_client.py 會把送出(→)和收回(←)的訊息都印出來。先打手刻的 server,再加上 --sdk 打 Day 10 的 devbench。

實測輸出:

對象:同資料夾裡手刻的 raw_server  (raw_server.py)
  → {"jsonrpc": "2.0", "method": "notifications/initialized"}

  沒有 ← 那一行,因為 notification 本來就不該有回應。

  (a) 協定層錯誤:方法不存在 → JSON-RPC error 物件
  → {"jsonrpc": "2.0", "id": 4, "method": "tools/nonexistent"}
  ← {"jsonrpc": "2.0", "id": 4, "error": {"code": -32601, "message": "Method not found", "data": {"method": "tools/nonexistent"}}}

  (b) 應用層錯誤:工具本身執行失敗 → 正常的 result,但 isError=true
  → {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "nope", "arguments": {}}}
  ← {"jsonrpc": "2.0", "id": 5, "result": {"content": [{"type": "text", "text": "沒有這個工具:nope"}], "isError": true}}

對象:Day 10 用官方 SDK 寫的 devbench server  (server.py)
  對方是 devbench v0.1.0
  協定版本 2025-06-18
  → {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
  ← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"annotations":{"destructiveHint":false,"idempotentHint":true,"readOnlyHint":true},"description":"讀取專案內的一個檔案並回傳內容(附行號)。","inputSchema":{"

兩種錯誤的處理方式完全相反。協定層錯誤只有 error,代表請求本身有問題,要給開發者修程式。應用層錯誤有 result,代表請求沒問題但執行失敗,應該原封不動餵回給模型。

如果當成協定錯誤丟出去,agent 迴圈直接中斷,模型連自我修正的機會都沒有。Day 13 實作 ReAct 時會再用到。

換成官方 SDK 寫的 server,同一支 client 握手、列工具、呼叫工具、兩種錯誤全部照走。回應裡的 "readOnlyHint":true 是 camelCase,Day 10 會看到同一個欄位在 Python 物件上叫 read_only_hint

五、踩坑筆記

日誌不准印到 stdout。stdout 是協定通道,隨手 print 一行 debug,對方就會把它當成 JSON-RPC 訊息解析。實測 SDK 2.2.0 的 client 會噴一段解析錯誤、跳過那行,連線沒斷;今天這支手刻 client 直接 json.loads,則會當場拋例外。所有日誌一律走 stderr。

記得 flush。Python 的 stdout 接到 pipe 時是整塊緩衝的,不 flush 的話小訊息會一直卡在緩衝區,看起來就像對方沒回應。

明天講這些訊息怎麼送到對方手上:傳輸協定 stdio vs Streamable HTTP,有狀態與無狀態。順便看看為什麼 server 設成 stateless,不代表你的程式就沒有狀態。


附錄:今天的完整程式碼

days/day08_jsonrpc/raw_server.py

"""Day 8:一個完全不用 SDK 的 MCP Server。

只有標準函式庫。目的是讓你看清楚:所謂 MCP Server,就是一個
「從 stdin 讀一行 JSON、往 stdout 寫一行 JSON」的迴圈。

MCP 的 stdio 傳輸用的是 newline-delimited JSON(一行一則訊息),
不是 LSP 那種 Content-Length 標頭。日誌一律寫 stderr,
因為 stdout 是協定專用通道,印上去的每一行都會被對方當成訊息解析。
"""

from __future__ import annotations

import json
import sys
from typing import Any

PROTOCOL_VERSION = "2025-06-18"

TOOLS = [
    {
        "name": "add",
        "description": "把兩個整數相加",
        "inputSchema": {
            "type": "object",
            "properties": {
                "a": {"type": "integer", "description": "第一個加數"},
                "b": {"type": "integer", "description": "第二個加數"},
            },
            "required": ["a", "b"],
        },
    },
    {
        "name": "echo",
        "description": "原封不動回傳輸入的文字",
        "inputSchema": {
            "type": "object",
            "properties": {"text": {"type": "string"}},
            "required": ["text"],
        },
    },
]

def log(msg: str) -> None:
    print(f"[raw_server] {msg}", file=sys.stderr, flush=True)

def send(msg: dict[str, Any]) -> None:
    sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n")
    sys.stdout.flush()

def ok(req_id: Any, result: dict[str, Any]) -> None:
    send({"jsonrpc": "2.0", "id": req_id, "result": result})

def err(req_id: Any, code: int, message: str, data: Any = None) -> None:
    error: dict[str, Any] = {"code": code, "message": message}
    if data is not None:
        error["data"] = data
    send({"jsonrpc": "2.0", "id": req_id, "error": error})

def call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
    if name == "add":
        total = int(args["a"]) + int(args["b"])
        return {"content": [{"type": "text", "text": str(total)}], "isError": False}
    if name == "echo":
        return {"content": [{"type": "text", "text": str(args["text"])}], "isError": False}
    # 工具「執行失敗」不是協定錯誤,要用 isError 回報,不是回 JSON-RPC error
    return {"content": [{"type": "text", "text": f"沒有這個工具:{name}"}], "isError": True}

def main() -> None:
    log("等待 stdin…")
    initialized = False

    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue

        try:
            msg = json.loads(line)
        except json.JSONDecodeError as exc:
            err(None, -32700, "Parse error", str(exc))
            continue

        method = msg.get("method")
        req_id = msg.get("id")           # 沒有 id = notification,不可以回應
        params = msg.get("params") or {}
        log(f"收到 {method}(id={req_id})")

        # ── 生命週期 ─────────────────────────────────────────────
        if method == "initialize":
            ok(req_id, {
                "protocolVersion": PROTOCOL_VERSION,
                "capabilities": {"tools": {"listChanged": False}},
                "serverInfo": {"name": "raw-server", "version": "0.0.1"},
                "instructions": "這是一個不用 SDK 手刻的示範 server。",
            })
            continue

        if method == "notifications/initialized":
            initialized = True
            log("握手完成,客戶端說它準備好了(這是 notification,我不回應)")
            continue

        # ── 握手前不准做事 ───────────────────────────────────────
        if not initialized and method != "ping":
            err(req_id, -32002, "尚未完成 initialize 握手", {"method": method})
            continue

        if method == "ping":
            ok(req_id, {})
        elif method == "tools/list":
            ok(req_id, {"tools": TOOLS})
        elif method == "tools/call":
            try:
                ok(req_id, call_tool(params["name"], params.get("arguments", {})))
            except KeyError as exc:
                err(req_id, -32602, "Invalid params", f"缺少欄位 {exc}")
        else:
            err(req_id, -32601, "Method not found", {"method": method})

    log("stdin 關閉,結束。")

if __name__ == "__main__":
    main()

days/day08_jsonrpc/raw_client.py

"""Day 8:手寫 JSON-RPC client,把 MCP 握手的每一個 byte 攤開來看。

這支 client 有兩個模式:

    uv run python days/day08_jsonrpc/raw_client.py          # 打自己人(raw_server.py)
    uv run python days/day08_jsonrpc/raw_client.py --sdk    # 打 Day 10 的 SDK server

第二個模式是重點:同一支手刻 client,可以直接跟官方 SDK 寫的 server
對話。這證明 SDK 沒有另外發明什麼,它就是照 JSON-RPC 2.0 在講話。
"""

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path
from typing import Any

HERE = Path(__file__).resolve()
ROOT = HERE.parents[2]
RAW_SERVER = HERE.with_name("raw_server.py")
SDK_SERVER = ROOT / "days" / "day10_mcp_server" / "server.py"

PROTOCOL_VERSION = "2025-06-18"

class RawSession:
    """一個 JSON-RPC over stdio 的最小客戶端。"""

    def __init__(self, server: Path) -> None:
        self.proc = subprocess.Popen(
            [sys.executable, str(server)],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,   # server 的日誌走 stderr,這裡先丟掉
            text=True,
            bufsize=1,
            cwd=str(ROOT),
        )
        self._next_id = 0

    # ── 低階:送一行、收一行 ────────────────────────────────────
    def _write(self, msg: dict[str, Any]) -> None:
        line = json.dumps(msg, ensure_ascii=False)
        print(f"  → {line[:180]}")
        assert self.proc.stdin
        self.proc.stdin.write(line + "\n")
        self.proc.stdin.flush()

    def _read(self) -> dict[str, Any]:
        assert self.proc.stdout
        line = self.proc.stdout.readline()
        if not line:
            raise RuntimeError("server 關閉了連線")
        print(f"  ← {line.strip()[:180]}")
        return json.loads(line)

    # ── 高階:request 要 id,notification 不要 ──────────────────
    def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
        self._next_id += 1
        self._write({
            "jsonrpc": "2.0",
            "id": self._next_id,
            "method": method,
            **({"params": params} if params is not None else {}),
        })
        resp = self._read()
        assert resp["id"] == self._next_id, "id 必須配對,否則你就串線了"
        return resp

    def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
        """notification 沒有 id,對方不會、也不准回應。"""
        self._write({
            "jsonrpc": "2.0",
            "method": method,
            **({"params": params} if params is not None else {}),
        })

    def close(self) -> None:
        if self.proc.stdin:
            self.proc.stdin.close()
        self.proc.wait(timeout=10)

def rule(title: str) -> None:
    print()
    print("─" * 72)
    print(title)
    print("─" * 72)

def walk_lifecycle(server: Path, label: str) -> None:
    print("=" * 72)
    print(f"對象:{label}  ({server.name})")
    print("=" * 72)

    s = RawSession(server)

    # ── ① initialize ────────────────────────────────────────────
    rule("① initialize:交換協定版本與能力")
    resp = s.request("initialize", {
        "protocolVersion": PROTOCOL_VERSION,
        "capabilities": {},
        "clientInfo": {"name": "raw-client", "version": "0.0.1"},
    })
    result = resp["result"]
    info = result.get("serverInfo") or result.get("server_info") or {}
    print(f"\n  對方是 {info.get('name')} v{info.get('version')}")
    print(f"  協定版本 {result.get('protocolVersion') or result.get('protocol_version')}")
    print(f"  宣告的能力 {list(result.get('capabilities', {}))}")

    # ── ② initialized(notification)─────────────────────────────
    rule("② notifications/initialized:沒有 id,所以沒有回應")
    s.notify("notifications/initialized")
    print("\n  沒有 ← 那一行,因為 notification 本來就不該有回應。")
    print("  這是 JSON-RPC 最容易搞錯的地方:有 id 才要回,沒 id 回了就是 bug。")

    # ── ③ tools/list ───────────────────────────────────────────
    rule("③ tools/list:問對方有什麼工具")
    resp = s.request("tools/list")
    tools = resp["result"]["tools"]
    print(f"\n  拿到 {len(tools)} 個工具:")
    for t in tools:
        schema = t.get("inputSchema") or t.get("input_schema") or {}
        print(f"    {t['name']:14s} {(t.get('description') or '').splitlines()[0][:40]}")
        print(f"    {'':14s} 必填參數 {schema.get('required', [])}")

    # ── ④ tools/call ───────────────────────────────────────────
    rule("④ tools/call:真的叫一次")
    name, args = ("add", {"a": 17, "b": 25}) if server == RAW_SERVER else ("list_files", {"pattern": "src/**/*.py", "limit": 3})
    resp = s.request("tools/call", {"name": name, "arguments": args})
    content = resp["result"].get("content", [])
    print(f"\n  {name}({args}) 回傳:")
    for block in content:
        for line in (block.get("text") or "").splitlines()[:6]:
            print(f"    {line}")

    # ── ⑤ 錯誤 ─────────────────────────────────────────────────
    rule("⑤ 兩種完全不同的「錯誤」")
    print("\n  (a) 協定層錯誤:方法不存在 → JSON-RPC error 物件")
    resp = s.request("tools/nonexistent")
    e = resp.get("error", {})
    print(f"      code={e.get('code')}  message={e.get('message')}")
    print("      -32601 是 JSON-RPC 標準碼:Method not found")

    print("\n  (b) 應用層錯誤:工具本身執行失敗 → 正常的 result,但 isError=true")
    bad_name, bad_args = ("nope", {}) if server == RAW_SERVER else ("read_file", {"path": "../../../etc/passwd"})
    resp = s.request("tools/call", {"name": bad_name, "arguments": bad_args})
    r = resp.get("result", {})
    print(f"      有 result 嗎?{'result' in resp}   isError={r.get('isError') or r.get('is_error')}")
    print(f"      內容 → {(r.get('content') or [{}])[0].get('text', '')[:80]}")
    print("\n  ★ 分清楚這兩種,你的 agent 才知道該重試、還是該告訴使用者。")

    s.close()
    print()

def main() -> None:
    if "--sdk" in sys.argv:
        walk_lifecycle(SDK_SERVER, "Day 10 用官方 SDK 寫的 devbench server")
        print("=" * 72)
        print("★ 同一支手刻 client,打得動官方 SDK 寫的 server。")
        print("  這就是「協定」的意義:兩邊只要遵守同一份規格,就能互通。")
        print("=" * 72)
    else:
        walk_lifecycle(RAW_SERVER, "同資料夾裡手刻的 raw_server")
        print("=" * 72)
        print("接著試試看:uv run python days/day08_jsonrpc/raw_client.py --sdk")
        print("同一支 client 去打 Day 10 用 SDK 寫的 server,會一模一樣地動。")
        print("=" * 72)

if __name__ == "__main__":
    main()

上一篇
Day 7:MCP 三大原語 Tools、Resources、Prompts
下一篇
Day 9:傳輸協定 stdio vs Streamable HTTP,有狀態與無狀態
系列文
協定、框架、架構:一條龍搞懂 AI Agent 是怎麼被造出來的10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言